import {Buffer} from 'node:buffer';
import {
	Agent as HttpAgent,
	request as httpRequest,
	type IncomingMessage,
	type RequestOptions,
} from 'node:http';
import test from 'ava';
import is from '@sindresorhus/is';
import type {Handler} from 'express';
import delay from 'delay';
import got, {
	Options,
	type BeforeRequestHook,
	type Headers,
	type Hooks,
	type OptionsInit,
	type RequestFunction,
} from '../source/index.js';
import withServer from './helpers/with-server.js';

const echoHeaders: Handler = (request, response) => {
	request.resume();
	response.end(JSON.stringify(request.headers));
};

test('preserves global defaults', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	const globalHeaders = await got('').json();
	const instanceHeaders = await got.extend()('').json();
	t.deepEqual(instanceHeaders, globalHeaders);
});

test('supports instance defaults', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	const instance = got.extend({
		headers: {
			'user-agent': 'custom-ua-string',
		},
	});
	const headers = await instance('').json<Headers>();
	t.is(headers['user-agent'], 'custom-ua-string');
});

test('supports invocation overrides', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	const instance = got.extend({
		headers: {
			'user-agent': 'custom-ua-string',
		},
	});
	const headers = await instance({
		headers: {
			'user-agent': 'different-ua-string',
		},
	}).json<Headers>();
	t.is(headers['user-agent'], 'different-ua-string');
});

test('carries previous instance defaults', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	const instanceA = got.extend({
		headers: {
			'x-foo': 'foo',
		},
	});
	const instanceB = instanceA.extend({
		headers: {
			'x-bar': 'bar',
		},
	});
	const headers = await instanceB('').json<Headers>();
	t.is(headers['x-foo'], 'foo');
	t.is(headers['x-bar'], 'bar');
});

test('custom headers (extend)', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	const options = {headers: {unicorn: 'rainbow'}};

	const instance = got.extend(options);
	const headers = await instance('').json<Headers>();
	t.is(headers.unicorn, 'rainbow');
});

test('extend overwrites arrays with a deep clone', t => {
	const x = () => {};
	const y = () => {};

	const beforeRequest = [x];
	const a = got.extend({hooks: {beforeRequest} as unknown as Hooks});
	beforeRequest[0] = y;
	t.deepEqual(a.defaults.options.hooks.beforeRequest, [x] as unknown as BeforeRequestHook[]);
	t.not(a.defaults.options.hooks.beforeRequest, beforeRequest as unknown as BeforeRequestHook[]);
});

test('hooks are merged on got.extend()', t => {
	const hooksA = [() => {}];
	const hooksB = [() => {}];

	const instanceA = got.extend({hooks: {beforeRequest: hooksA}});

	const extended = instanceA.extend({hooks: {beforeRequest: hooksB}});
	t.deepEqual(extended.defaults.options.hooks.beforeRequest, [...hooksA, ...hooksB]);
});

test('custom endpoint with custom headers (extend)', withServer, async (t, server) => {
	server.all('/', echoHeaders);

	const instance = got.extend({headers: {unicorn: 'rainbow'}, prefixUrl: server.url});
	const headers = await instance('').json<Headers>();
	t.is(headers.unicorn, 'rainbow');
	t.not(headers['user-agent'], undefined);
});

test('no tampering with defaults', t => {
	t.throws(() => {
		got.defaults.options.prefixUrl = 'http://google.com';
	});

	t.is(got.defaults.options.prefixUrl, '');
});

test('can set defaults to `new Options(...)`', t => {
	const instance = got.extend({
		mutableDefaults: true,
		followRedirect: false,
	});

	t.notThrows(() => {
		instance.defaults.options = new Options({
			followRedirect: false,
		}, undefined, instance.defaults.options);
	});

	t.false(instance.defaults.options.followRedirect);

	t.notThrows(() => {
		instance.defaults.options = new Options({});
	});

	t.true(instance.defaults.options.followRedirect);
});

test('can set mutable defaults using got.extend', t => {
	const instance = got.extend({
		mutableDefaults: true,
		followRedirect: false,
	});

	t.notThrows(() => {
		instance.defaults.options.followRedirect = true;
	});

	t.true(instance.defaults.options.followRedirect);
});

test('only plain objects are freezed', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	const instance = got.extend({
		agent: {
			http: new HttpAgent({keepAlive: true}),
		},
		mutableDefaults: true,
	});

	t.notThrows(() => {
		(instance.defaults.options.agent as any).http.keepAlive = true;
	});
});

test('defaults are cloned on instance creation', t => {
	const options: OptionsInit = {hooks: {beforeRequest: [() => {}]}};
	const instance = got.extend(options);
	const context = {
		foo: {},
	};

	t.notThrows(() => {
		options.context = context;
		delete options.hooks!.beforeRequest![0];
	});

	t.not(options.context!.foo, instance.defaults.options.context.foo);
	t.not(options.hooks!.beforeRequest, instance.defaults.options.hooks.beforeRequest);
});

test('ability to pass a custom request method', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	let isCalled = false;

	const request: RequestFunction = (...arguments_: [
		string | URL | RequestOptions,
		(RequestOptions | ((response: IncomingMessage) => void))?,
		((response: IncomingMessage) => void)?,
	]) => {
		isCalled = true;
		// @ts-expect-error Overload error
		return httpRequest(...arguments_);
	};

	const instance = got.extend({request});
	await instance('');

	t.true(isCalled);
});

test('does not include the `request` option in normalized `http` options', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	let isCalled = false;

	const request: RequestFunction = (...arguments_: [
		string | URL | RequestOptions,
		(RequestOptions | ((response: IncomingMessage) => void))?,
		((response: IncomingMessage) => void)?,
	]) => {
		isCalled = true;

		t.false(Reflect.has(arguments_[0] as RequestOptions, 'request'));

		// @ts-expect-error Overload error
		return httpRequest(...arguments_);
	};

	const instance = got.extend({request});
	await instance('');

	t.true(isCalled);
});

test('should pass an options object into an initialization hook after .extend', withServer, async (t, server, got) => {
	t.plan(1);

	server.get('/', echoHeaders);

	let first = true;

	const instance = got.extend({
		hooks: {
			init: [
				options => {
					if (!first) {
						t.deepEqual(options, {});
					}

					first = false;
				},
			],
		},
	});

	await instance('', {});
});

test('hooks aren\'t overriden when merging options', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	let isCalled = false;
	const instance = got.extend({
		hooks: {
			beforeRequest: [
				() => {
					isCalled = true;
				},
			],
		},
	});

	await instance({});

	t.true(isCalled);
});

test('extend with custom handlers', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	const instance = got.extend({
		handlers: [
			(options, next) => {
				options.headers.unicorn = 'rainbow';
				return next(options);
			},
		],
	});
	const headers = await instance('').json<Headers>();
	t.is(headers.unicorn, 'rainbow');
});

test('extend with instances', t => {
	const a = got.extend({prefixUrl: new URL('https://example.com/')});
	const b = got.extend(a);
	t.is(b.defaults.options.prefixUrl.toString(), 'https://example.com/');
});

test('extend with a chain', t => {
	const a = got.extend({prefixUrl: 'https://example.com/'});
	const b = got.extend(a, {headers: {foo: 'bar'}});
	t.is(b.defaults.options.prefixUrl.toString(), 'https://example.com/');
	t.is(b.defaults.options.headers.foo, 'bar');
});

test('async handlers', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	const instance = got.extend({
		handlers: [
			(options, next) => {
				if (options.isStream) {
					return next(options);
				}

				return (async () => {
					const result = await next(options);
					// @ts-expect-error Manual tests
					result.modified = true;

					return result;
				})();
			},
		],
	});

	const promise = instance('');
	t.true(is.function(promise.cancel));
	// @ts-expect-error Manual tests
	t.true((await promise).modified);
});

test('async handlers can throw', async t => {
	const message = 'meh';

	const instance = got.extend({
		handlers: [
			async () => {
				throw new Error(message);
			},
		],
	});

	await t.throwsAsync(instance('https://example.com'), {
		instanceOf: Error,
		message,
	});
});

test('setting dnsCache to true points to global cache', t => {
	const a = got.extend({
		dnsCache: true,
	});

	const b = got.extend({
		dnsCache: true,
	});

	t.is(a.defaults.options.dnsCache, b.defaults.options.dnsCache);
});

test('waits for handlers to finish', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	const instance = got.extend({
		handlers: [
			async (options, next) => {
				await delay(1000);
				return next(options);
			},
			async (options, next) => {
				options.headers.foo = 'bar';
				return next(options);
			},
		],
	});

	const {foo} = await instance('').json<{foo: 'bar'}>();
	t.is(foo, 'bar');
});

test('does not append to internal _init on new requests', withServer, async (t, server, got) => {
	server.get('/', echoHeaders);

	const instance = got.extend({
		mutableDefaults: true,
	});

	const {length} = (instance.defaults.options as any)._init;

	await got('', {
		context: {},
	});

	t.is((instance.defaults.options as any)._init.length, length);
});

test('extend clones searchParams object', t => {
	const searchParameters = {foo: 'bar', page: '1'};
	const instance = got.extend({searchParams: searchParameters});

	// Mutate the original
	searchParameters.foo = 'changed';
	searchParameters.page = '999';

	// Instance should have the original values (converted to URLSearchParams)
	const instanceParameters = instance.defaults.options.searchParams as URLSearchParams;
	t.is(instanceParameters.get('foo'), 'bar');
	t.is(instanceParameters.get('page'), '1');
});

test('extend clones URLSearchParams instance', t => {
	const searchParameters = new URLSearchParams({foo: 'bar', page: '1'});
	const instance = got.extend({searchParams: searchParameters});

	// Mutate the original
	searchParameters.set('foo', 'changed');
	searchParameters.set('page', '999');

	// Instance should have the original values
	const instanceParameters = instance.defaults.options.searchParams as URLSearchParams;
	t.is(instanceParameters.get('foo'), 'bar');
	t.is(instanceParameters.get('page'), '1');
	t.not(instanceParameters, searchParameters);
});

test('extend handles string searchParams', t => {
	const searchParameters = 'foo=bar&page=1';
	const instance = got.extend({searchParams: searchParameters});

	// String gets converted to URLSearchParams
	const instanceParameters = instance.defaults.options.searchParams as URLSearchParams;
	t.is(instanceParameters.get('foo'), 'bar');
	t.is(instanceParameters.get('page'), '1');
});

test('extended instances carry searchParams', t => {
	const instanceA = got.extend({
		searchParams: {foo: 'bar'},
	});

	const instanceB = instanceA.extend({
		searchParams: {page: '1'},
	});

	const instanceParametersB = instanceB.defaults.options.searchParams as URLSearchParams;
	t.is(instanceParametersB.get('foo'), 'bar');
	t.is(instanceParametersB.get('page'), '1');
});

test('extend creates independent searchParams copies', t => {
	const searchParameters = {foo: 'bar'};
	const instanceA = got.extend({searchParams: searchParameters, mutableDefaults: true});
	const instanceB = got.extend({searchParams: searchParameters, mutableDefaults: true});

	// Modify instanceA's searchParams
	(instanceA.defaults.options.searchParams as URLSearchParams).set('foo', 'modified');

	// InstanceB should be unaffected
	t.is((instanceB.defaults.options.searchParams as URLSearchParams).get('foo'), 'bar');
});

test('got.extend() with responseType works at runtime', withServer, async (t, server) => {
	server.get('/json', (_request, response) => {
		response.writeHead(200, {'content-type': 'application/json'});
		response.end('{"data": "test"}');
	});

	server.get('/buffer', (_request, response) => {
		response.writeHead(200, {'content-type': 'application/octet-stream'});
		response.end(Buffer.from('binary'));
	});

	// Test responseType: 'json' works
	const jsonClient = got.extend({
		prefixUrl: server.url,
		responseType: 'json',
	});

	const jsonResponse = await jsonClient('json');
	t.deepEqual(jsonResponse.body, {data: 'test'});

	// Test responseType: 'buffer' works
	const bufferClient = got.extend({
		prefixUrl: server.url,
		responseType: 'buffer',
	});

	const bufferResponse = await bufferClient('buffer');
	t.true(Buffer.isBuffer(bufferResponse.body));
	t.is(bufferResponse.body.toString(), 'binary');

	// Test resolveBodyOnly works with extended responseType
	const jsonBodyClient = got.extend({
		prefixUrl: server.url,
		responseType: 'json',
		resolveBodyOnly: true,
	});

	const jsonBody = await jsonBodyClient('json');
	t.deepEqual(jsonBody, {data: 'test'});
});
